서버 전용 환경 변수를 클라이언트에서 숨기기

서버 전용 환경 변수를 클라이언트에서 숨기기

한눈에 보기

환경 변수의 이름을 숨기는 것과 비밀값이 브라우저에 전달되지 않게 하는 것은 전혀 다른 문제다. 서버 전용 값은 server-only 모듈 안에서 읽고, 외부 서비스 호출도 서버에서 끝낸 뒤 클라이언트에는 화면에 필요한 결과만 전달해야 한다. 또한 JavaScript 번들만이 아니라 RSC payload, Route Handler 응답, 오류 메시지와 로그까지 전달 경계로 보아야 한다.

웹 애플리케이션을 만들다 보면 결제사 비밀키, 데이터베이스 접속 문자열, 사내 API 토큰처럼 브라우저가 알아서는 안 되는 값을 사용하게 된다. 이런 값을 .env에 넣는 것만으로 안전해졌다고 생각하기 쉽다. 저장소에 하드 코딩하지 않았으니 해결된 것처럼 보이기 때문이다.

하지만 .env값의 주입 방법일 뿐, 접근 제어 장치가 아니다. 어떤 코드가 그 값을 읽는지, 그 코드가 어느 환경에서 실행되는지, 반환값이 어떤 직렬화 경로를 거치는지가 실제 보안 경계를 결정한다.

이 글에서는 Next.js App Router를 예로 들어 다음 질문에 답해 본다.

목차

환경 변수는 보안 저장소가 아니다

다음 코드는 겉보기에는 안전해 보인다.

# .env.local
PAYMENT_SECRET_KEY=sk_example_do_not_use
const secret = process.env.PAYMENT_SECRET_KEY;

비밀키가 소스 코드에 직접 쓰이지 않았고 .env.local.gitignore에 포함되어 있다. 그러나 안전 여부를 판단하려면 코드가 실행되는 위치를 함께 봐야 한다.

"use client";

export function DebugPanel() {
  const value = process.env.NEXT_PUBLIC_PAYMENT_KEY;
  return <pre>{value}</pre>;
}

클라이언트 코드가 값을 사용할 수 있게 공개 접두사를 붙이는 순간 그 값은 브라우저 번들에 포함될 수 있다. 화면에서 렌더링하지 않아도 개발자 도구, 내려받은 JavaScript 파일, source map 등을 통해 확인할 수 있다. 변수명이 난해하거나 코드를 압축해도 달라지지 않는다.

핵심 기준

브라우저가 기능 수행을 위해 가져야 하는 값은 사용자가 읽을 수 있는 값이다. 난독화, Base64 인코딩, 프런트엔드 암호화는 비밀을 만들지 못한다. 복호화에 필요한 정보와 실행 코드도 결국 사용자에게 전달되기 때문이다.

반대로 서버 프로세스만 읽는 환경 변수라고 해서 자동으로 안전한 것도 아니다. 다음과 같이 응답에 실어 보내면 서버 전용 접두사 여부와 무관하게 유출된다.

export async function GET() {
  return Response.json({
    ok: true,
    debug: {
      paymentKey: process.env.PAYMENT_SECRET_KEY,
    },
  });
}

환경 변수 보안은 “어디에 저장했는가?”보다 “어디까지 흘러가는가?”의 문제다.

공개 설정과 비밀값을 먼저 구분한다

모든 환경 변수를 비밀로 다룰 필요는 없다. 공개되어도 되는 설정과 유출되면 권한을 행사할 수 있는 자격 증명을 먼저 나누면 경계가 선명해진다.

종류 예시 브라우저 전달 관리 기준
공개 설정 서비스 기본 URL, 공개 지도 스타일 ID, 분석 도구 공개 ID 가능 값 변경과 환경별 차이를 관리
서버 설정 내부 서비스 주소, 배치 크기, 기능 플래그 기본값 보통 불필요 서버에서만 읽고 필요한 상태만 전달
비밀값 DB URL, 결제 비밀키, OAuth client secret 금지 비밀 저장소, 최소 권한, 교체 정책 적용
단기 자격 증명 Presigned URL, 짧은 수명의 업로드 토큰 조건부 가능 범위·대상·만료 시간을 강하게 제한

판단하기 애매하다면 이 질문이 유용하다.

이 값을 획득한 사용자가 별도의 인증 없이 외부 시스템에서 권한을 행사할 수 있는가?

답이 “그렇다”라면 비밀값이다. 예를 들어 공개 결제 키는 결제 화면을 초기화하는 식별자일 수 있지만, 결제 승인·취소 권한을 가진 비밀키는 서버에만 있어야 한다. 이름에 public이나 secret이 들어가는지는 중요하지 않다. 실제로 부여된 권한이 분류 기준이다.

환경 변수 전체를 하나의 객체에 모은 뒤 공용 모듈에서 내보내는 방식도 피하는 편이 좋다.

// 좋지 않은 예: 클라이언트 코드가 이 모듈을 import할 가능성이 있다.
export const config = {
  appName: process.env.NEXT_PUBLIC_APP_NAME,
  databaseUrl: process.env.DATABASE_URL,
  paymentSecret: process.env.PAYMENT_SECRET_KEY,
};

공개 설정과 서버 설정을 모듈 수준에서 분리하면 import 경로 자체가 경계 역할을 할 수 있다.

src/
├── config/
│   ├── public.ts       # 브라우저에 공개해도 되는 값
│   └── server.ts       # 비밀값과 서버 설정
└── features/
    └── payment/
        ├── payment-service.ts
        └── payment-view.tsx

이 분리는 환경 변수를 설정과 비밀값으로 나누기에서 다룬 설정 분류를 코드 구조로 옮긴 것이다.

Next.js에서 값이 브라우저로 이동하는 경로

App Router에서는 Server Component와 Client Component가 한 화면을 함께 구성한다. 이때 파일이 서버에서 실행된다는 사실만 확인해서는 부족하다. 서버가 만든 값이 직렬화되어 브라우저로 전달되는 경로도 살펴야 한다.

flowchart LR
    E["서버 환경 변수"] --> S["Server Component
서버 모듈"] S --> X["외부 API 호출"] X --> V["화면용 데이터"] V --> R["RSC payload / HTML"] R --> B["브라우저"] S -. "비밀값 자체를 props로 전달" .-> L["유출"] S -. "응답·오류에 포함" .-> L E -. "공개 접두사로 번들에 포함" .-> L

대표적인 전달 경로는 다음과 같다.

  1. Client Component가 참조하는 공개 환경 변수
  2. 서버에서 Client Component로 넘기는 props
  3. Server Component의 렌더링 결과와 RSC payload
  4. Route Handler와 Server Action의 정상·오류 응답
  5. 브라우저로 전달되는 HTML의 data-* 속성이나 인라인 스크립트
  6. 공개 source map, 디버그 엔드포인트, 오류 추적 도구의 사용자 컨텍스트

다음 코드는 Server Component에서만 환경 변수를 읽지만 안전하지 않다.

import { PaymentForm } from "./payment-form";

export default function CheckoutPage() {
  return (
    <PaymentForm
      providerKey={process.env.PAYMENT_SECRET_KEY}
    />
  );
}

PaymentForm이 Client Component라면 providerKey는 브라우저가 컴포넌트를 구성할 수 있도록 직렬화되어 전달된다. 값의 출발점은 서버였지만 도착점은 브라우저다.

서버에서 읽었다는 사실은 충분조건이 아니다

클라이언트에 전달하는 props, JSON 응답, HTML, RSC payload에는 비밀값을 넣지 않아야 한다. 서버는 비밀값을 사용하는 곳이어야지, 비밀값을 중계하는 곳이어서는 안 된다.

Server Component와 Client Component의 실행 경계를 먼저 이해하고 싶다면 Next.js Server Component와 Client Component 경계를 함께 보면 좋다.

NEXT_PUBLIC_ 값은 빌드 결과의 일부다

Next.js에서 NEXT_PUBLIC_ 접두사가 붙은 환경 변수는 클라이언트 코드에서 사용할 수 있다. 일반적으로 정적인 참조는 빌드 과정에서 값으로 치환되어 브라우저용 JavaScript에 포함된다.

const analyticsId = process.env.NEXT_PUBLIC_ANALYTICS_ID;

개념적으로는 프로덕션 번들에 다음과 비슷한 값이 남는 셈이다.

const analyticsId = "analytics_example_123";

따라서 NEXT_PUBLIC_PAYMENT_SECRET처럼 이름만 공개 접두사로 바꾸는 것은 해결책이 아니다. 이는 “클라이언트에서도 읽을 수 있게 해 달라”는 선언에 가깝다.

또 하나 놓치기 쉬운 점은 빌드 시점 고정이다. 컨테이너 이미지를 스테이징에서 빌드한 뒤 같은 이미지를 프로덕션으로 승격하더라도, 번들에 인라인된 공개 값은 빌드 당시 값을 유지할 수 있다.

sequenceDiagram
    participant CI as CI 빌드
    participant IMG as 컨테이너 이미지
    participant STG as 스테이징
    participant PRD as 프로덕션
    participant WEB as 브라우저

    CI->>IMG: NEXT_PUBLIC_API_BASE를 번들에 인라인
    IMG->>STG: 동일 이미지 배포
    IMG->>PRD: 동일 이미지 승격
    PRD->>WEB: 빌드 당시 값이 포함된 JS 전달

이 성질은 보안뿐 아니라 설정 운영에도 영향을 준다.

비밀값이 공개 번들에 들어간 사고가 발생했다면 해당 값을 즉시 폐기하고 새 키로 교체해야 한다. “다음 배포에서 코드를 지웠으니 괜찮다”가 아니다. 과거 번들을 내려받은 사람이 이미 값을 확보했을 수 있기 때문이다.

동적 접근에 기대지 않기

const env = process.env처럼 간접 접근하면 정적 인라인 규칙이 다르게 동작할 수 있다. 그렇다고 이를 비밀값 은닉 기법으로 사용해서는 안 된다. 빌드 도구의 최적화 규칙은 보안 경계가 아니며 버전과 실행 환경에 따라 달라질 수 있다.

서버 전용 설정 모듈 만들기

서버 비밀값을 여러 파일에서 직접 읽으면 사용처를 추적하기 어렵다. 서버 전용 설정 모듈 한 곳에서 읽고 검증한 뒤 필요한 서버 코드만 이 모듈을 import하도록 만든다.

아래 코드는 특정 프로젝트를 복사한 것이 아니라 구조를 설명하기 위해 재구성한 예시다.

// src/config/server.ts
import "server-only";
import { z } from "zod";

const ServerEnvSchema = z.object({
  DATABASE_URL: z.string().url(),
  PAYMENT_SECRET_KEY: z.string().min(20),
  PAYMENT_API_BASE_URL: z.string().url(),
});

const parsed = ServerEnvSchema.safeParse({
  DATABASE_URL: process.env.DATABASE_URL,
  PAYMENT_SECRET_KEY: process.env.PAYMENT_SECRET_KEY,
  PAYMENT_API_BASE_URL: process.env.PAYMENT_API_BASE_URL,
});

if (!parsed.success) {
  console.error(
    "Invalid server environment variables",
    parsed.error.flatten().fieldErrors,
  );
  throw new Error("Server configuration is invalid");
}

export const serverEnv = Object.freeze(parsed.data);

import "server-only"는 이 모듈이 Client Component 쪽으로 잘못 import될 때 빌드 단계에서 문제를 발견하도록 돕는다. 런타임에 비밀을 암호화해 주는 기능은 아니지만, 코드 경계를 어기는 실수를 빠르게 드러내는 안전장치가 된다.

설정 검증에서는 값을 그대로 로그로 출력하지 않는 것도 중요하다.

// 좋지 않은 예
console.error("Invalid config", process.env);

// 더 나은 예
console.error("Missing required config", {
  databaseUrl: Boolean(process.env.DATABASE_URL),
  paymentSecretKey: Boolean(process.env.PAYMENT_SECRET_KEY),
});

오류를 진단할 때는 “값이 있는가”, “형식이 올바른가”만 남기고 실제 값은 출력하지 않는다. 초기 검증 전략은 설정 검증을 애플리케이션 시작 시점에 해야 하는 이유와 연결된다.

공개 설정은 별도 파일에 제한적으로 선언한다.

// src/config/public.ts
import { z } from "zod";

const PublicEnvSchema = z.object({
  NEXT_PUBLIC_APP_NAME: z.string().min(1),
  NEXT_PUBLIC_SUPPORT_URL: z.string().url(),
});

export const publicEnv = PublicEnvSchema.parse({
  NEXT_PUBLIC_APP_NAME: process.env.NEXT_PUBLIC_APP_NAME,
  NEXT_PUBLIC_SUPPORT_URL: process.env.NEXT_PUBLIC_SUPPORT_URL,
});

이때 공개 설정 파일에는 서버 비밀값을 한 줄도 넣지 않는다. “객체에서 비밀 필드만 사용하지 않으면 된다”가 아니라 처음부터 같은 객체에 존재하지 않게 만드는 것이 안전하다.

비밀키는 서버에서 소비하고 결과만 반환한다

결제사 API를 호출하는 예를 생각해 보자. 클라이언트가 필요한 것은 비밀키가 아니라 결제 준비 결과다. 따라서 비밀키는 서버 서비스 내부에서 소비하고, 호출자에게는 허용된 필드만 반환한다.

// src/features/payment/payment-service.ts
import "server-only";
import { serverEnv } from "@/config/server";

type PreparePaymentInput = {
  orderId: string;
  amount: number;
};

type PaymentSession = {
  sessionId: string;
  expiresAt: string;
};

export async function preparePayment(
  input: PreparePaymentInput,
): Promise<PaymentSession> {
  const response = await fetch(
    `${serverEnv.PAYMENT_API_BASE_URL}/sessions`,
    {
      method: "POST",
      headers: {
        authorization: `Bearer ${serverEnv.PAYMENT_SECRET_KEY}`,
        "content-type": "application/json",
      },
      body: JSON.stringify(input),
      cache: "no-store",
    },
  );

  if (!response.ok) {
    throw new Error(`Payment provider failed: ${response.status}`);
  }

  const data: unknown = await response.json();
  return toPaymentSession(data);
}

function toPaymentSession(data: unknown): PaymentSession {
  if (
    typeof data !== "object" ||
    data === null ||
    !("sessionId" in data) ||
    !("expiresAt" in data) ||
    typeof data.sessionId !== "string" ||
    typeof data.expiresAt !== "string"
  ) {
    throw new Error("Unexpected payment provider response");
  }

  return {
    sessionId: data.sessionId,
    expiresAt: data.expiresAt,
  };
}

여기서 중요한 부분은 세 가지다.

  1. 외부 API 인증 헤더는 서버 서비스 안에서만 만든다.
  2. 외부 응답을 그대로 반환하지 않고 화면에 필요한 형태로 변환한다.
  3. 외부 서비스의 상세 오류 본문을 사용자 응답에 그대로 노출하지 않는다.

외부 API가 디버깅을 위해 요청 헤더나 토큰 일부를 응답에 포함할 가능성도 있다. 응답 전체를 그대로 통과시키는 프록시보다 명시적인 DTO로 변환하는 방식이 안전한 이유다.

// 피하고 싶은 형태
const providerResponse = await response.json();
return providerResponse;

// 허용 필드만 선택
return {
  sessionId: providerResponse.sessionId,
  expiresAt: providerResponse.expiresAt,
};

이 원칙은 데이터베이스 조회에도 동일하게 적용된다. ORM이 반환한 사용자 객체 전체를 Client Component에 넘기지 말고 화면에 필요한 프로필 DTO를 만든다. 비밀번호 해시나 내부 권한 플래그처럼 환경 변수는 아니지만 서버에 남아야 하는 데이터도 같은 경계에서 보호된다.

Route Handler가 새로운 유출 지점이 되지 않게 하기

브라우저에서 버튼을 눌러 결제를 준비해야 한다면 Route Handler를 BFF(Backend for Frontend)로 둘 수 있다.

// app/api/payment-sessions/route.ts
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireUser } from "@/auth/require-user";
import { preparePayment } from "@/features/payment/payment-service";

const RequestSchema = z.object({
  orderId: z.string().uuid(),
});

export async function POST(request: Request) {
  const user = await requireUser();
  const body = RequestSchema.parse(await request.json());

  const order = await findPayableOrder({
    orderId: body.orderId,
    userId: user.id,
  });

  if (!order) {
    return NextResponse.json(
      { code: "ORDER_NOT_PAYABLE" },
      { status: 404 },
    );
  }

  const session = await preparePayment({
    orderId: order.id,
    amount: order.totalAmount,
  });

  return NextResponse.json({
    sessionId: session.sessionId,
    expiresAt: session.expiresAt,
  });
}

서버에 두었다고 요청자가 자동으로 신뢰되는 것은 아니다. Route Handler와 Server Action은 브라우저가 호출할 수 있는 서버 진입점이므로 일반 API와 같은 검증이 필요하다.

특히 다음 코드는 비밀키 자체는 숨기지만 다른 취약점을 만든다.

// 좋지 않은 예: 브라우저가 보낸 금액을 그대로 결제 준비에 사용한다.
const { orderId, amount } = await request.json();
return Response.json(
  await preparePayment({ orderId, amount }),
);

공격자는 개발자 도구나 별도 HTTP 클라이언트로 amount를 바꿀 수 있다. 서버는 orderId와 현재 사용자로 주문을 다시 조회하고 금액을 결정해야 한다. 비밀값을 서버에 두는 것은 보안 설계의 시작이지 완성이 아니다.

오류 처리에서도 내부 정보와 사용자 메시지를 분리한다.

try {
  const session = await preparePayment(input);
  return Response.json(session);
} catch (error) {
  reportServerError(error, {
    operation: "prepare-payment",
    orderId: input.orderId,
  });

  return Response.json(
    { code: "PAYMENT_TEMPORARILY_UNAVAILABLE" },
    { status: 503 },
  );
}

reportServerError에 전달하는 컨텍스트에도 토큰, 전체 요청 헤더, 결제 정보 원문을 넣지 않는다. 서버 로그는 브라우저 번들보다 접근자가 적지만, 운영자·외부 로그 서비스·보존 정책이라는 별도의 노출면을 가진다.

브라우저가 직접 업로드해야 한다면

대용량 파일 업로드처럼 브라우저가 외부 스토리지와 직접 통신하는 편이 효율적인 경우가 있다. 이때도 장기 비밀키를 브라우저에 전달하지 않는다. 서버가 범위와 만료 시간이 제한된 일회성 권한을 발급한다.

sequenceDiagram
    participant C as 브라우저
    participant A as Next.js 서버
    participant S as 오브젝트 스토리지

    C->>A: 업로드 권한 요청
    A->>A: 사용자·파일명·크기·MIME 검증
    A->>S: 서버 비밀키로 서명
    S-->>A: 짧은 수명의 Presigned URL
    A-->>C: 업로드 URL과 제한 정보
    C->>S: 파일 직접 업로드
    S-->>C: 업로드 결과

예시 Route Handler는 다음과 같이 구성할 수 있다.

import { z } from "zod";
import { requireUser } from "@/auth/require-user";
import { createUploadUrl } from "@/storage/create-upload-url";

const UploadRequestSchema = z.object({
  fileName: z.string().min(1).max(120),
  contentType: z.enum(["image/jpeg", "image/png", "image/webp"]),
  size: z.number().int().positive().max(10 * 1024 * 1024),
});

export async function POST(request: Request) {
  const user = await requireUser();
  const input = UploadRequestSchema.parse(await request.json());

  const objectKey = `users/${user.id}/${crypto.randomUUID()}`;
  const signed = await createUploadUrl({
    objectKey,
    contentType: input.contentType,
    expiresInSeconds: 300,
  });

  return Response.json({
    uploadUrl: signed.url,
    objectKey,
    expiresAt: signed.expiresAt,
  });
}

Presigned URL 자체는 유효 시간 동안 권한을 가진다. 따라서 아무에게나 발급하거나 로그에 무분별하게 남겨서는 안 된다. 다음 조건을 최대한 좁힌다.

이 구조는 S3 Presigned URL로 업로드 부하 분산하기에서 더 자세히 다룬다.

빌드 시점 설정과 런타임 설정 구분하기

환경 변수 문제를 다룰 때는 공개·비밀 구분과 함께 언제 값이 확정되는가도 기록해야 한다.

구분 값이 확정되는 시점 대표 용도 변경 시 고려사항
클라이언트 공개 설정 대체로 프로덕션 빌드 시점 공개 API 주소, 분석 ID 번들 재빌드와 캐시 무효화 필요
서버 런타임 설정 서버 요청 처리 또는 프로세스 시작 시점 내부 API 주소, 비밀키 플랫폼·렌더링 방식에 따라 읽는 시점 확인
정적 생성 결과 빌드 또는 재검증 시점 미리 렌더링한 페이지 데이터 페이지 재생성 전까지 이전 값이 남을 수 있음

비밀키를 교체할 때도 실행 모델에 따라 절차가 달라진다. 서버리스 함수가 새 인스턴스를 시작할 때 환경 변수를 읽는지, 장기 실행 프로세스가 시작 시 한 번만 읽는지, 배포 플랫폼이 값 변경 후 자동 재배포하는지 확인해야 한다.

일반적인 무중단 교체는 다음 순서를 따른다.

  1. 외부 서비스에서 새 키를 생성한다.
  2. 애플리케이션이 새 키를 사용하도록 배포한다.
  3. 새 키 사용이 정상인지 관측한다.
  4. 이전 키를 폐기한다.
  5. 로그와 이력에서 예상치 못한 사용이 없는지 확인한다.

단일 키만 허용되는 외부 서비스라면 짧은 중단이나 배포 순서를 별도로 설계해야 한다. 설정값 변경이 단순한 문자열 수정처럼 보여도 실제로는 배포 전략의 일부다.

설정 문서에 시점을 적는다

각 환경 변수에 public/server, build/runtime, secret/non-secret, owner, rotation을 기록해 두면 배포 사고를 크게 줄일 수 있다.

# 문서화를 위한 예시이며 실제 비밀값은 기록하지 않는다.
PAYMENT_SECRET_KEY:
  exposure: server
  resolution: runtime
  secret: true
  owner: payments
  rotation: every-90-days

환경 변수 유출을 검증하는 방법

코드 리뷰만으로는 번들에 어떤 값이 들어갔는지 확신하기 어렵다. 프로덕션 빌드 결과와 브라우저 네트워크를 직접 확인해야 한다.

1. 합성 비밀값으로 번들 검사하기

실제 키를 로컬 테스트에 넣지 말고 검색하기 쉬운 가짜 값을 사용한다.

PAYMENT_SECRET_KEY=SENTINEL_SERVER_SECRET_7F31A
NEXT_PUBLIC_APP_NAME=ExampleApp

프로덕션 빌드 후 공개 정적 자산에서 sentinel을 검색한다.

pnpm build

rg --fixed-strings \
  "SENTINEL_SERVER_SECRET_7F31A" \
  .next/static

검색 결과가 나온다면 서버 비밀값이 클라이언트 자산에 들어간 경로를 추적해야 한다. 빌드 출력 디렉터리는 Next.js 버전과 배포 방식에 따라 달라질 수 있으므로 실제 배포 산출물도 함께 검사한다.

실제 운영 키로 검색하지 않는다

명령 이력, CI 로그, 화면 공유에 비밀값이 남을 수 있다. 번들 누출 테스트에는 반드시 합성 sentinel을 사용한다.

2. RSC payload와 네트워크 응답 확인하기

브라우저 개발자 도구의 Network 탭에서 다음을 본다.

정상 경로만 확인하면 부족하다. 잘못된 입력, 인증 만료, 외부 API 장애, 타임아웃을 의도적으로 발생시켜 오류 응답도 확인한다.

3. import 경계 자동화하기

server-only 외에 정적 분석 규칙을 추가할 수 있다. 예를 들어 Client Component가 src/config/server를 import하지 못하도록 ESLint의 import 제한 규칙을 둔다.

// eslint.config.mjs의 개념 예시
export default [
  {
    files: ["client component files"],
    rules: {
      "no-restricted-imports": [
        "error",
        {
          patterns: [
            {
              group: ["@/config/server", "@/server-only-modules"],
              message: "Server-only modules cannot be imported here.",
            },
          ],
        },
      ],
    },
  },
];

실제 프로젝트에서는 모든 .tsx가 Client Component인 것은 아니므로 파일 배치나 별도 디렉터리 규칙에 맞게 범위를 조정해야 한다. 규칙의 목적은 프레임워크 판단을 대체하는 것이 아니라 실수 가능성을 줄이는 것이다.

4. 배포 산출물과 운영 경로까지 확인하기

마지막으로 다음 항목을 확인한다.

이 검증은 “현재 화면에서 보이지 않는다”가 아니라 “사용자가 받을 수 있는 모든 출력에 존재하지 않는다”는 것을 확인하는 과정이다.

자주 하는 오해와 실패 패턴

.env에 넣었으니 안전하다

.env는 소스와 설정을 분리할 뿐이다. 해당 값을 읽은 코드가 브라우저 번들에 포함되거나 API 응답으로 반환하면 값도 노출된다.

변수명을 짧게 만들거나 난독화하면 찾기 어렵다

공격자는 변수명을 찾을 필요가 없다. 네트워크 요청, 문자열 패턴, 외부 서비스 엔드포인트 주변 코드를 분석할 수 있다. 난독화는 접근 권한을 제거하지 않는다.

프런트엔드에서 암호화한 뒤 사용하면 된다

브라우저가 값을 복호화해 사용해야 한다면 복호화 키나 동등한 로직도 브라우저에 있어야 한다. 이는 비밀의 위치를 바꾸는 것에 불과하다.

Server Component에서 읽으면 무엇을 반환해도 된다

Server Component의 실행은 서버에서 일어나지만 렌더링 결과 일부는 브라우저로 전달된다. Client Component props와 직렬화되는 데이터는 공개 데이터로 취급해야 한다.

Route Handler로 한 번 감싸면 안전하다

Route Handler가 외부 API의 무인증 프록시가 되면 비밀키 권한을 누구나 대신 사용할 수 있다. 인증, 인가, 입력 검증, rate limit이 없으면 키 문자열은 숨겨도 권한은 노출한 셈이다.

Git 이력에서 지웠으니 키를 계속 써도 된다

한 번 커밋되거나 번들로 배포된 비밀은 유출된 것으로 간주해야 한다. 파일과 이력을 정리하는 작업과 별개로 키를 폐기하고 교체한다.

운영 체크리스트

설계

API 경계

배포와 검증

마무리

서버 전용 환경 변수를 숨기는 가장 확실한 방법은 값을 영리하게 감추는 것이 아니라 브라우저가 그 값을 필요로 하지 않는 구조를 만드는 것이다.

비밀키는 서버 전용 모듈에서 읽고 검증한다. 외부 시스템 호출도 서버에서 수행한다. Client Component, RSC payload, Route Handler에는 화면과 다음 동작에 필요한 최소 데이터만 전달한다. 브라우저가 외부 스토리지에 직접 접근해야 한다면 장기 자격 증명 대신 범위가 좁고 빨리 만료되는 임시 권한을 발급한다.

마지막으로 보안 경계는 코드의 의도만으로 증명되지 않는다. 프로덕션 빌드 산출물과 실제 네트워크 응답을 살펴봐야 한다. .env는 비밀을 저장하는 위치일 수 있지만, 비밀을 지켜 주는 경계는 서버 코드와 데이터 흐름의 설계다.

관련 노트

참고 자료